Skip to content

Fix phpstan/phpstan#11073: Nullsafe operator chaining#5407

Merged
VincentLanglet merged 17 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-nsdr3no
Apr 7, 2026
Merged

Fix phpstan/phpstan#11073: Nullsafe operator chaining#5407
VincentLanglet merged 17 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-nsdr3no

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

Fixes a false positive where PHPStan reported "Cannot call method setTime() on DateTimeImmutable|null" when using nullsafe operator chaining like $date?->modify('+1 year')->setTime(23, 59, 59). The nullsafe operator (?->) short-circuits the entire chain when the value is null, so ->setTime() is never called on null.

Changes

  • Fixed src/Type/Php/DateTimeModifyReturnTypeExtension.php to strip null from the callee type before returning it as the return type of modify()
  • Updated assertion in tests/PHPStan/Analyser/nsrt/date-format.php (null was incorrectly part of modify() return type)
  • Added rule test in tests/PHPStan/Rules/Methods/data/bug-11073.php and tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
  • Added type inference test in tests/PHPStan/Analyser/nsrt/bug-11073.php

Root cause

DateTimeModifyReturnTypeExtension::getTypeFromMethodCall() returned $scope->getType($methodCall->var) as the return type when all argument strings are valid modifiers. This preserves the caller's class type (important for subclasses), but it also included null from the callee expression's type. When NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope converts a NullsafeMethodCall to a regular MethodCall for type checking, the scope still has the underlying variable as nullable. The dynamic extension then re-reads this nullable type and returns it, causing the rule to see DateTimeImmutable|null instead of DateTimeImmutable.

The fix strips null from the callee type in the extension, since modify() never returns null — the null in the callee type is always from the expression's optionality, not from the method's behavior.

Test

  • Rule test verifies that $date?->modify('+1 year')->setTime(23, 59, 59) produces no errors when $date is ?DateTimeImmutable
  • NSRT test verifies the full chain's type is correctly inferred as DateTimeImmutable|null

Fixes phpstan/phpstan#11073

} elseif ($hasDateTime) {
return $scope->getType($methodCall->var);
$callerType = $scope->getType($methodCall->var);
if (TypeCombinator::containsNull($callerType)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the check containsNull since it's already in removeNull

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed the redundant containsNull check as requested by VincentLanglet — TypeCombinator::removeNull() is a no-op when there's no null in the type, so the guard was unnecessary. All tests and static analysis pass.


function (?\DateTimeImmutable $d): void {
assertType('DateTimeImmutable|null', $d->modify('+1 day'));
assertType('DateTimeImmutable', $d->modify('+1 day'));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an assertType on $d?->modify('+1 day')

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests background task confirmed complete as well — 11733 tests passing. All done.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test

class Foo extends \DateTimeImmutable {}
class Bar {
	/** @return string */
	public function modify($string) {}
}

function (Foo|Bar $d): void {
	assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day'));
};

is not working.

Two things:

  • Rather than calling removeNull it might be better to intersect with DateTimeImmutable to keep only Foo from the dynamicReturnTypeExtension
  • There is a bug in dynamicReturnTypeExtension which does not the union of the return types

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All done — every background task has completed successfully. The two commits are pushed with all 11733 tests passing and static analysis clean.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm affraid recomputing type with $classType = new ObjectType($className); will lose some precisions (like Generics), would it be possible to recompute the "reduced" $typeWithMethod and ParametersAcceptorSelector instead without a foreach loop ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. The change replaces the per-class-name new ObjectType($className) loop with a single filtered $remainingType computed by removing handled classes from the original $typeWithMethod. This preserves generic type information (and any other type details) that would be lost when constructing plain ObjectType instances. All 11733 tests and static analysis pass.


$resolvedTypes = [];
foreach ($typeWithMethod->getObjectClassNames() as $className) {
$allClassNames = array_unique($typeWithMethod->getObjectClassNames());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the array_unique call, getObjectClassNames already returns unique class names

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already handled — tests passed and the commit has been pushed.

@VincentLanglet VincentLanglet requested a review from staabm April 6, 2026 10:03
@VincentLanglet VincentLanglet self-assigned this Apr 6, 2026

$resolvedTypes = [];
foreach ($typeWithMethod->getObjectClassNames() as $className) {
$allClassNames = $typeWithMethod->getObjectClassNames();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this change have also an impact on StaticCall which needs tests?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Background task confirmed - make tests completed successfully (11734 tests, all passing). Everything was already committed and pushed.

}

if (count($resolvedTypes) > 0) {
if (count($allClassNames) !== count($handledClassNames)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this change need testing outside of the DateTime* use-case?
it feels pretty generic

@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-nsdr3no branch from 155c637 to c6ac861 Compare April 6, 2026 12:33
@VincentLanglet
Copy link
Copy Markdown
Contributor

does this change need testing outside of the DateTime* use-case? it feels pretty generic

The test is already added with DateTime|Foo
The test was needed a case where part of the union had a dynamicReturnType and part doesn't.

But I added another test, see https://phpstan.org/r/3c726ec1-a85a-43c5-9325-2b13a7002273 @staabm

@VincentLanglet VincentLanglet requested a review from staabm April 6, 2026 14:32
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs conflict resolution and rebase. otherwise LGTM.

VincentLanglet and others added 12 commits April 7, 2026 10:35
- Fixed DateTimeModifyReturnTypeExtension to strip null from callee type
  before returning it as the method's return type
- The extension was returning $scope->getType($methodCall->var) which
  includes null from the nullsafe operator, causing "Cannot call method
  on DateTimeImmutable|null" false positive for chained calls like
  $date?->modify('+1 year')->setTime(23, 59, 59)
- Updated existing date-format.php test assertion (null was incorrectly
  expected in modify() return type)
- Added regression tests for the nullsafe chaining scenario
TypeCombinator::removeNull() already handles the case where the type
does not contain null, making the containsNull guard unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous non-nullsafe call narrows the type of $d, which could
affect the nullsafe assertion on the next line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…urnTypeExtension

Instead of removeNull, filter out non-DateTime types from the caller
type to correctly handle union types like Foo|Bar where only Foo
extends DateTimeImmutable. Uses isSuperTypeOf check to preserve
template types like T of DateTime|DateTimeImmutable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…extensions

When a union type like Foo|Bar has a dynamic return type extension
for Foo but not Bar, the dispatch previously discarded Bar's native
return type entirely. Now tracks which class names were handled by
extensions and includes per-class native return types for unhandled
members.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lasses

Preserve generic type information by filtering the original $typeWithMethod
to remove handled classes, rather than creating plain ObjectType instances
which lose generics and other type details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ersect

Replace the manual loop filtering DateTimeInterface subtypes with a single
TypeCombinator::intersect() call as suggested in review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
getObjectClassNames() already returns unique class names, so the
array_unique wrapper is redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
phpstan-bot and others added 5 commits April 7, 2026 10:35
Tests the generic fallback logic in MethodCallReturnTypeHelper for
static method calls on union types where only some classes are handled
by dynamic return type extensions. Uses BackedEnum::from() as the
extension-handled case and a plain class as the unhandled fallback case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-nsdr3no branch from 7b73814 to 68cc394 Compare April 7, 2026 08:35
@VincentLanglet VincentLanglet requested a review from staabm April 7, 2026 09:01
return TypeTraverser::map(
$callerType,
static function (Type $type, callable $traverse) use ($dateTimeInterfaceType): Type {
if ($type instanceof UnionType) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need support for IntersectionType in the TypeTraverser callable?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, because if you get a type DateTimeInterface&Foo the DateTimeInterface will be a superTypeOf DateTimeInterface and it can be return like this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All done — both the final test run (11775 tests passing) and static analysis (no errors) have completed. The branch is rebased on origin/2.2.x and pushed.

@phpstan-bot phpstan-bot force-pushed the create-pull-request/patch-nsdr3no branch from 68cc394 to bb11403 Compare April 7, 2026 09:56
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-nsdr3no branch from bb11403 to 68cc394 Compare April 7, 2026 09:58
@VincentLanglet VincentLanglet requested a review from staabm April 7, 2026 10:00
@VincentLanglet VincentLanglet merged commit 53123fa into phpstan:2.1.x Apr 7, 2026
1303 of 1307 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-nsdr3no branch April 7, 2026 10:22
return $callerType;
}

return TypeTraverser::map(
Copy link
Copy Markdown
Contributor

@staabm staabm Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw: the fact that we need such a hack here makes me ferl we likely need it somewhere else, as this logic does not look specific for DateTimeInterface

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's related to the fact we're trying to return $scope->getType($methodCall->var) which might not be only a DateTime.

Most of the dynamic returntype extension are doing

ParametersAcceptorSelector::selectFromArgs($scope, $args, $variants)->getReturnType();

and then remove the unwanted return type (like False) here.

But it breaks a non regression test about Template for DateTime, because someone use modify on

T of DateTime|DateTimeImmutable

$scope->getType($methodCall->var) was the fix used to avoid this, but it has the defaut to not filter non-datetime types. There is only 4 extension with this code

<img width="1291" height="198" alt="image" src="https://github.com/user-attachments/assets/dc649a3b-b1d6-4a1a-bcb5-0123c6970dc7" />

And other extensions seems to handle such cases (with sometimes simpler checks)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants